# NLP.TM | tensorflow做基础的文本分类
【NLP.TM】
本人有关自然语言处理和文本挖掘方面的学习和笔记,欢迎大家关注。
往期回顾:
文本分类是自然语言处理中入门级的任务,但是已经被普遍应用于情感分析等重要领域。近期在复习和整理的过程发现,词袋模型+机器学习以及fasttext为是一个文本分类的重要基线,但是很多难度较高的问题使用这两个比较简单的基线并不work,因此要尝试开始使用深度学习,而CNN网络结构作为目前比较经典的基线模型,今天给大家谈一下。
数据与深度学习模型
数据使用的是这个网站给出的商品评价数据,中文的:
https://github.com/BUPTLdy/Sentiment-Analysis
本文以模型结构和具体实现为基础,有关原理可以移步这几篇文章看看,但是如果本身对深度学习没有概念,我建议还是从深度学习基础开始看起吧:
我讲解的word2vector文章:NLP.TM | 再看word2vector
卷积神经网络讲解:https://blog.csdn.net/weixin_42451919/article/details/81381294
TextCNN经典论文:Convolutional Neural Networks for Sentence Classification
原理搞懂剩下就是怎么建立了,我的整套模型是以如下形式构建的。
embedding:word2vector,dim=100, seqence_len=50
convolution layer 1:
conv1d, filters=10, kernel_size=5, strides=1
maxpooling1d, poolsize=2, strides=2
relu
convolution layer 2:
conv1d, filters=10, kernel_size=3, strides=1
maxpooling1d, poolsize=2, strides=2
relu
convolution layer 1:
conv1d, filters=10, kernel_size=3, strides=1
maxpooling1d, poolsize=2, strides=2
relu
flatten
dense, 64
dense, class_size=2
开始写代码
此处代码部分借鉴了这个网址,鸣谢:
https://www.libinx.com/2018/text-classification-cnn-by-tensorflow/
另外,长文长代码预警,可以收藏再看(收藏了也要记得一定要看啊),或者在电脑上看。代码在这个链接下:
https://gitee.com/chashaozgr/noteLibrary/tree/master/nlp_trial/classification
数据预处理
数据预处理这块不是我们今天讨论的重点,所以不需要太多清洗,此处预处理上只做了分词,用的是其实已经有点小落后但是依旧流行的结巴。
import pandas as pd
import numpy as np
import jieba
# 数据加载
def data_loader(path):
return pd.read_excel(path, header=None, index=None)
# 分词
def cw(x):
return list(jieba.cut(x))
# 读入文件路径
POS_PATH = "../data/pos.xls"
NEG_PATH = "../data/neg.xls"
# 加载正类和负类文档
pos = data_loader(POS_PATH)
neg = data_loader(NEG_PATH)
# 进行批量化分词
pos['words'] = pos[0].apply(cw)
neg['words'] = neg[0].apply(cw)
# 从pd中取出数据
pos_word_list = []
for item in pos['words']:
pos_word_list.append(item)
neg_word_list = []
for item in neg['words']:
neg_word_list.append(item)
# 分别进行存储
POS_CW_PATH = "../data/pos_cw.txt"
NEG_CW_PATH = "../data/neg_cw.txt"
with open(POS_CW_PATH, "w", encoding="utf8") as f:
for item in pos_word_list:
f.writelines("%s\n" % ("\t".join(item)))
with open(NEG_CW_PATH, "w", encoding="utf8") as f:
for item in neg_word_list:
f.writelines("%s\n" % ("\t".join(item)))
pandas具有很强的批处理能力,使用起来效果很好,代码也很整洁,但是在实际工业应用中,其实并不推荐使用pandas,因为随着数据的增加,由于pandas会将全量数据读入到内存,这将会使得内存占用过高,因此不推荐在工业环境使用pandas,而推荐使用数据流(open+readline之类的方式)来对数据进行批量处理,例如mapreduce。
数据集生成
这个也是机器学习项目的基操,就是区分训练集和测试集,然后由于在后续的深度学习中要进行批量梯度下降,所以个人习惯在此处就把每个batch分好。
from sklearn.model_selection import train_test_split
import numpy as np
# 训练集和测试集生成
train_X, test_X, train_y, test_y = train_test_split(all_data, all_labels, test_size=0.2, random_state=10)
# 乱序训练集
index = [i for i in range(len(train_X))]
np.random.shuffle(index)
train_X = np.array(train_X)[index].tolist()
train_y = np.array(train_y)[index].tolist()
# 训练集batch划分
idx = 0
batch_idx = 0
tmp_batch_x = []
tmp_batch_y = []
fout = open(BATCH_PATH + str(batch_idx), "w", encoding="utf8")
while idx < len(train_X):
fout.write("%s\t%s\n" % (train_y[idx], "\t".join(train_X[idx])))
idx = idx + 1
if idx % BATCH_SIZE == 0:
fout.close()
batch_idx = batch_idx + 1
fout = open(BATCH_PATH + str(batch_idx), "w", encoding="utf8")
fout.close()
# 测试集处理
index = [i for i in range(len(test_X))]
np.random.shuffle(index)
test_X = np.array(test_X)[index].tolist()
test_y = np.array(test_y)[index].tolist()
fout = open(BATCH_PATH + "test", "w", encoding="utf8")
for idx in range(len(test_X)):
fout.write("%s\t%s\n" % (test_y[idx], "\t".join(test_X[idx])))
分点说明~
训练集和测试集划分使用的是sklearn中的traintestsplit函数,非常简单方便,只需要看懂API文档然后使用即可
训练集要分batch,为了方便所以整了一波乱序,我写的这个乱序的方式非常方便,非常建议大家get起来
训练集batch划分其实就是每batch_size分一个组,放到batch文件里面即可
测试集更简单,乱序后直接存即可
word2vec预训练模型
然后就到word2vec预训练部分了。此处我使用的预训练语料也是这套数据,可能数据会比较少每个人感觉训练出来的效果一般吧,但是能凑合着用,大家主要看过程。
from gensim.models.word2vec import Word2Vec
POS_SW_PATH = "../data/pos_cw.txt"
NEG_SW_PATH = "../data/neg_cw.txt"
N_DIM = 100 # word2vec的数量
MIN_COUNT = 3 # 保证出现的词数足够做才进入词典
w2v_EPOCH = 20 # w2v的训练迭代次数
# 读取文件
pos_data = []
with open(POS_SW_PATH, encoding="utf8") as f:
for line in f:
ll = line.strip().split("\t")
pos_data.append(ll)
neg_data = []
with open(NEG_SW_PATH, encoding="utf8") as f:
for line in f:
ll = line.strip().split("\t")
neg_data.append(ll)
all_data = pos_data + neg_data
# word2vector词向量预备
imdb_w2v = Word2Vec(size=N_DIM, min_count=MIN_COUNT)
imdb_w2v.build_vocab(all_data)
# 把所有未进入词表的都转为unk_
for sentence_idx in range(len(all_data)):
for word_item_idx in range(len(all_data[sentence_idx])):
if all_data[sentence_idx][word_item_idx] not in imdb_w2v.wv.vocab:
all_data[sentence_idx][word_item_idx] = "unk_"
# 重新构建词汇表
imdb_w2v = Word2Vec(size=N_DIM, min_count=MIN_COUNT)
imdb_w2v.build_vocab(all_data)
# 训练
imdb_w2v.train(all_data, total_examples=len(all_data), epochs=w2v_EPOCH)
# 模型保存
# imdb_w2v.save("../data/w2v_model/word2vec_20190626.model")
SAVE_PATH = "../data/w2v_model/word2vec_20190626.model"
SAVE_PATH_WORD2ID_DICT = "../data/w2v_model/word2id_20190626.model"
fout_model = open(SAVE_PATH, "w", encoding="utf8")
fout_word2id_dict = open(SAVE_PATH_WORD2ID_DICT, "w", encoding="utf8")
idx = 0
for k,v in imdb_w2v.wv.vocab.items():
str_get = "%s\t%s\n" % (k, "\t".join([str(i) for i in imdb_w2v.wv[k]]))
fout_model.write(str_get)
str_get = "%s\t%s\n" % (k, idx)
fout_word2id_dict.write(str_get)
idx = idx + 1
fout_model.close()
fout_word2id_dict.close()
word2vec的训练非常常规,我在之前的篇章其实已经讲过,此处两个点需要重点提出。
为了保证词向量的确定性,出现次数太少额词汇不应该出现在词汇表里,但是这些词不出现在词汇表里又会导致这些词没有词向量,因此此处我们选择把这些词汇转为unk形式,训练的时候就当做unk这个单词来进行,为了实现这个功能,进行了两次词汇表构建:(build_vocab)。
另外,词汇保存,此处选择了最朴素的txt保存法,另一方面,由于tensorflow后续的embedding_lookup只能使用id形式获取词向量,所以我保存了一份word2id的词汇表。
深度学习模型
为了保证模型的可替换性,所以非常建议大家把深度学习模型本身写成类的形式,然后往外暴露相同的接口,此处给一个demo。
class TextCNN(object):
"""文本分类,CNN模型"""
def __init__(self, config):
self.config = config
# 三个待输入的数据
self.input_x = tf.placeholder(tf.int32, [None, self.config.seq_length], name='input_x')
self.input_y = tf.placeholder(tf.float32, [None, self.config.num_classes], name='input_y')
self.embedding_placeholder = tf.placeholder(tf.float32,
[self.config.vocab_size, self.config.embedding_dim],
name="embedding_placeholder")
self.keep_prob = tf.placeholder(tf.float32, name='keep_prob')
self.cnn()
def cnn(self):
"""CNN模型"""
# 词向量映射
with tf.name_scope("embedding"):
embedding_inputs = tf.nn.embedding_lookup(self.embedding_placeholder, self.input_x)
with tf.name_scope("cnn1"):
# CNN layer
conv1 = tf.layers.conv1d(embedding_inputs, 10, 5, name='conv1')
# global max pooling layer
# gmp1 = tf.reduce_max(conv1, reduction_indices=[1], name='gmp1')
maxpool1 = tf.layers.max_pooling1d(conv1, pool_size=2, strides=2, name="maxpool1")
gmp1 = tf.nn.relu(maxpool1)
with tf.name_scope("cnn2"):
# CNN layer
conv2 = tf.layers.conv1d(gmp1, 10, 3, name='conv2')
maxpool2 = tf.layers.max_pooling1d(conv2, pool_size=2, strides=2, name="maxpool2")
gmp2 = tf.nn.relu(maxpool2)
with tf.name_scope("cnn3"):
# CNN layer
conv3 = tf.layers.conv1d(gmp2, 10, 3, name='conv3')
maxpool3 = tf.layers.max_pooling1d(conv3, pool_size=2, strides=2, name="maxpool3")
gmp3 = tf.nn.relu(maxpool3)
with tf.name_scope("score"):
# 全连接层,后面接dropout以及relu激活
fc = tf.layers.flatten(gmp3)
fc = tf.layers.dense(fc, 64, name='fc1')
fc = tf.contrib.layers.dropout(fc, self.keep_prob)
fc = tf.nn.relu(fc)
# 分类器
self.logits = tf.layers.dense(fc, self.config.num_classes, name='fc2')
self.softmax = tf.nn.softmax(self.logits)
self.y_pred_cls = tf.argmax(tf.nn.softmax(self.logits), 1) # 预测类别
with tf.name_scope("optimize"):
# 损失函数,交叉熵
cross_entropy = tf.nn.softmax_cross_entropy_with_logits(logits=self.logits, labels=self.input_y)
self.loss = tf.reduce_mean(cross_entropy)
# 优化器
self.optim = tf.train.AdamOptimizer(learning_rate=self.config.learning_rate).minimize(self.loss)
with tf.name_scope("accuracy"):
# 准确率
correct_pred = tf.equal(tf.argmax(self.input_y, 1), self.y_pred_cls)
self.acc = tf.reduce_mean(tf.cast(correct_pred, tf.float32))
这里暴露出去的接口有这些:
数据输入类:Inputx, inputy, embeddingplaceholder, keepprob
结果输出类:logits, softmax, ypredcls, loss, optim, acc
剩下的就是根据上面所说的网络结构进行,值得注意的是此处我是用的是tf.layers的接口进行构建,这个接口非常接近keras,用起来十分方便,比tf.nn系列少了很多计算中间层维数计算的操作。
另外有些特别的参数我也单独列了出来,用一个类来包装。
class TCNNConfig(object):
"""CNN配置参数"""
embedding_dim = 100 # 词向量维度
seq_length = 50 # 序列长度
num_classes = 2 # 类别数
keep_prob = 0.5 # dropout保留比例
learning_rate = 1e-4 # 学习率
batch_size = 64 # 每批训练大小
num_batches = 263 # 一共有多少batch
num_epochs = 20 # 总迭代轮次
print_per_batch = 100 # 每多少轮输出一次结果
这块看注释就很明显啦,很多地方会用到,所以就集中放在这里,目前由于只有CNN模型,后续可能会根据模型多样化再做一个更加完善的Config函数或者字典。
必要的数据读取
后续需要对数据进行读取,由于这块代码量较多且有一定通用性,所以就单独写成工具函数了。
def loadWord2Vec(filename):
vocab = []
embd = []
# cnt = 0
vocab_dic = {}
with open(filename, encoding="utf8") as f:
idx = 0
for line in f:
ll = line.strip().split("\t")
if len(ll) != 101:
continue
vocab.append(ll[0])
embd.append([float(i) for i in ll[1:]])
vocab_dic[ll[0]] = idx
idx = idx + 1
return vocab, embd, vocab_dic
def load_batch(batch_name, batch_idx, classes, sequence_len, word2id_dict):
input_path = "../data/batch_data/%s_%s" % (batch_name, batch_idx)
input_x = []
input_y = []
with open(input_path, encoding="utf8") as f:
for line in f:
ll = line.strip().split("\t")
if ll[0] != "1" and ll[0] != "0":
continue
input_y_get = [0 for i in range(classes)]
input_y_get[int(ll[0])] = 1
input_y.append(input_y_get)
tmp_sent = []
if len(ll[1:]) >= sequence_len:
tmp_sent = ll[1:sequence_len + 1]
else:
tmp_sent = ll[1:]
while len(tmp_sent) < sequence_len:
tmp_sent.append(-1)
if len(tmp_sent) != sequence_len:
print(len(tmp_sent))
print(tmp_sent)
for idx in range(sequence_len):
if tmp_sent[idx] in word2id_dict:
tmp_sent[idx] = word2id_dict[tmp_sent[idx]]
elif tmp_sent[idx] == -1:
tmp_sent[idx] = -1
else:
tmp_sent[idx] = word2id_dict["unk_"]
input_x.append(tmp_sent)
return input_x, input_y
这两块分别是读取word2vector模型的工具函数以及读取一个batch的工具函数,另外由于进行测试阶段也要进行类似的操作所以后续测试集读取也是使用该函数进行。
主函数
终于到主函数了,上面其实完成了不少基础性工作,所以此处我们就可以轻松愉快地开始整主函数。
首先是初始化,这里包括下面工作:
包导入:各种import
Word2vec模型导入:loadWord2Vec
配置信息导入:TCNNConfig
模型导入:TextCNN
import tensorflow as tf
from model_get import *
from utils import *
from sklearn.metrics import roc_auc_score
W2V_PATH = "../data/w2v_model/word2vec_20190626.model"
vocab, embd, vocab_dic = loadWord2Vec(W2V_PATH)
vocab_size = len(vocab)
embedding_dim = len(embd[0])
embedding_placeholder = embd
config = TCNNConfig()
keep_prob = config.keep_prob
config.vocab_size = vocab_size
model = TextCNN(config)
然后就可以直接开始session了,由于模型等多个工作在其他函数下做完,所以此处直接启动session就好了。
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for i in range(config.num_epochs):
for batch_idx in range(config.num_batches):
input_x, input_y = load_batch("20190626", batch_idx, config.num_classes, config.seq_length, vocab_dic)
# 训练
sess.run(model.optim,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
# batch阶段结果显示
if batch_idx % config.print_per_batch == 0:
loss = sess.run(model.loss,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
acc = sess.run(model.acc,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
print("epoch: %s, batch_idx: %s, loss: %s, acc: %s" % (i, batch_idx, loss, acc))
input_x, input_y = load_batch("20190626", "test", config.num_classes, config.seq_length, vocab_dic)
acc = sess.run(model.acc,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
# epoch阶段结果显示——测试集检测
input_x, input_y = load_batch("20190626", "test", config.num_classes, config.seq_length, vocab_dic)
test_prod = sess.run(model.softmax,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
test_res = sess.run(model.y_pred_cls,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
auc = roc_auc_score([i[1] for i in input_y], [i[1] for i in test_prod])
p, r, f1score = model_rep([i[1] for i in input_y], test_res)
print("test epoch: %s, acc: %s, precision: %s, recall: %s. f1: %s, auc: %s" % (i, acc, p, r, f1score, auc))
# 测试集计算
input_x, input_y = load_batch("20190626", "test", config.num_classes, config.seq_length, vocab_dic)
acc = sess.run(model.acc,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
test_prod = sess.run(model.softmax,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
test_res = sess.run(model.y_pred_cls,
feed_dict={"input_x:0": input_x, "input_y:0": input_y,
"embedding_placeholder:0": embedding_placeholder,
"keep_prob:0": config.keep_prob})
auc = roc_auc_score([i[1] for i in input_y], [i[1] for i in test_prod])
p, r, f1score = model_rep([i[1] for i in input_y], test_res)
print("test result, acc: %s, precision: %s, recall: %s. f1: %s, auc: %s" % (acc, p, r, f1score, auc))
这里包含4大部分工作:
训练:重头戏,把每一代feed如optim,这里其实就体现了TextCNN下暴露特定形式接口的优点,更加简洁,同时要是更换模型,此处不需要修改代码,具有即插即用的方便性。
batch阶段结果显示:每过特定的batch,就将目前的loss和acc情况展示,用于记录训练过程loss和acc的变化情况
epoch阶段结果显示——测试集检测:每过一个epoch,就拿测试集来看看目前的结果情况,查看了一些常见的分类指标
测试集计算——在最后阶段,查看测试集下最后的结果。
模型评估
此处是非常经典的分类问题,所以主要考察的是precision、recall、F1score和AUC4个指标,此处给出出于自己习惯而写的工具函数
from sklearn.metrics import precision_score, recall_score, f1_score
def model_rep(y_true, y_pred, average="micro"):
p = precision_score(y_true, y_pred, average=average)
r = recall_score(y_true, y_pred, average=average)
f1score = f1_score(y_true, y_pred, average=average)
return p, r, f1score
由于AUC只能用于二分类,所以我并没有写在这个工具函数里面,而是单独使用。
from sklearn.metrics import roc_auc_score
auc = roc_auc_score([i[1] for i in input_y], [i[1] for i in test_prod])
此处的计算需要注意roc_auc_score具体要求输入数据的结构,具体自己看API可能更加好理解哈(其实应该自己学会查的哈,此处就直接把传送门给大家了)。
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.rocaucscore.html#sklearn.metrics.rocaucscore
总结
今天详细地为大家展示了tensorflow版本的一个深度学习文本分类方法,这里只给大家展示一个基线模型——CNN,为的是让大家知道tensorflow项目怎么整,以及具体应用可能存在的坑,此处画一个重点吧:
预训练模型通过embedding_lookup读入,当然的,你也可以自己构建一个W变量,然后弄成可训练的方式
embedding_lookup读入的时候,需要把文字转为数字,因此需要一个word2id的词典
由于训练的时候数据是分批feed进入的,所以大家可以事先把数据分批,然后分次读入,当然的这是个内存较小时的方案,如果大家觉得自己的内存够或者数据集大小不大,那可以不事先分批,而是全部读入内存后,分块feed如模型中训练,均可
深度学习核心部分可单独写成一个类,暴露规范化接口,这样你能用一个主函数测试很多个不同的模型
代码其实还有很多训练策略等方面的改进,例如动态调整学习率、Adam+SGD分阶段训练等,可自行尝试
feed的时候,dict内元素,建议使用"变量名:0"的字符串形式,这个方式个人测试看来是所有feed方式中坑最少的一个,之前用纯变量名、纯字符串都偶尔会出比较奇怪的坑。